35 — 灯光、着色与 Shader

Three.js Journey · Bruno Simon · 难度:简单

35 — 灯光、着色与 Shader 课程海报

简介

当涉及到 shader 时,光照是一个非常困难的话题,仅凭这一点就足以证明使用 Three.js 的合理性。

在 Three.js 中实现灯光非常简单,只需实例化一个类,例如 DirectionalLight 或 PointLight,并将该实例添加到 scene 中即可。

但在这些预先制作好的类背后,是极其复杂的算法,它们试图使用复杂的基于物理的公式来再现真实世界的光照,更不用说灯光设置是如何被注入到 shader 中的了。

不用自己编写那些算法,真是太轻松了,对吧?不过,你猜怎么着?我们要自己动手实现它们。

你可能会想,为什么我们要这样自讨苦吃呢,这么做有多个原因。

首先,是为了学习。我们将要探索的各种公式和概念会提升你的 shader 水平,并且可以应用到许多其他主题中。

其次,你并不总是需要 Three.js 内置的灯光。如果你创建了一个自定义 shader,并想添加一个简单的明暗效果,让它看起来像是有些光从侧面照来,那么祝你在自定义 shader 中实现 Three.js 灯光时好运。

第三,是为了创造酷炫的效果,我们将在接下来的课程中看到。

我们不会把这个概念推进到 Three.js 内置灯光的程度,但我们会创建一些最常见的灯光,即点光源(point light)、平行光(directional light)和环境光(ambient light):

lesson demo video preview

另外,请注意我们要做的实现并不是基于物理的。它更像是一种老派的、高性能的做法,与 Phong shading 非常相似,只是做了些微调。

为了避免视觉瑕疵,大部分计算都会在 fragment shader 中完成。不过,这些计算也可以很容易地在 vertex shader 中完成,这实际上就是我们所说的 Gouraud shading。

准备工作

初始项目已经包含以下内容:

环境光

让我们从三者中最简单的环境光开始。它并不是最有影响的灯光,但从它入手可以帮助我们理解将要采用的结构,以及如何把计算出的光照与 material 的初始颜色结合起来。

环境光会在物体表面施加均匀的光照,无论其朝向如何。它并不真实,但少量使用可以提亮物体处于阴影中的部分,就好像光线在墙壁上反弹后回到物体上一样。

我们打算从一开始就把所有与灯光相关的代码放在一个单独的函数中。

在 fragment shader 中,在 main() 之前,创建一个返回 vec3 的 ambientLight 函数:

vec3 ambientLight()
{
    return vec3(0.0, 0.0, 0.0);
}

void main()
{
    vec3 color = uColor;

    // Final color
    gl_FragColor = vec4(color, 1.0);
    #include <tonemapping_fragment>
    #include <colorspace_fragment>
}

使用单独的函数是好的做法,但不仅仅是为了其他项目。这样做,我们就能够通过再次调用该函数来添加更多灯光。我们将在本课后面演示这一点。

我们返回一个 vec3,因为灯光可以是彩色的,而目前看起来灯光是关闭的,因为我们把它设为 0.0, 0.0, 0.0。

这听起来可能有点傻,但这实际上是一个绝佳的机会,来理解我们应该如何把光照与物体的原始颜色结合起来。

在 main() 中,紧接在创建 color 变量之后,创建一个值为 vec3(0.0) 的 light 变量,并使用 += 将 ambientLight() 的输出加到它上面:

void main()
{
    vec3 color = uColor;

    // Lights
    vec3 light = vec3(0.0);
    light += ambientLight();

    // ...
}

light 将包含我们将要计算的各种光照信息,从 ambientLight() 开始。但我们要如何把它与 color 结合起来呢?

给个提示。在现实生活中,如果没有光,物体被感知到的颜色应该是什么?没错,是黑色。

把 light 与初始的 color 结合起来,只需将两者相乘即可:

void main()
{
    vec3 color = uColor;

    // Lights
    vec3 light = vec3(0.0);
    light += ambientLight();
    color *= light;

    // ...
}

一个常见的错误是把 light 加到 color 上。是的,每个灯光都会被加进 light,但 light 需要乘以物体的颜色。

回到我们的 ambientLight():我们想返回黑色以外的东西。

我们将添加两个参数,灯光颜色 vec3 lightColor 和灯光强度 float lightIntensity,我们要将它们相乘并返回:

vec3 ambientLight(vec3 lightColor, float lightIntensity)
{
    return lightColor * lightIntensity;
}

我们需要相应地更新调用该函数的方式,我们将从一个暗淡的白色开始:

void main()
{
    // ...
    light += ambientLight(vec3(1.0), 0.2);
    // ...
}

我们可以进行一个有趣的测试,把灯光设为纯红色,并使用 debug panel 将物体的颜色改为蓝色:

void main()
{
    // ...
    light += ambientLight(vec3(1.0, 0.0, 0.0), 0.2);
    // ...
}
lesson demo video preview

我们再也看不到这些物体了。这合理吗?

在现实生活中,如果一个物体是纯蓝色的,而唯一的光源是红色的,你就看不到这个物体,因为它会吸收光线,而不会将其反射回你的眼睛。

让我们配置一个非常暗淡的白色灯光:

void main()
{
    // ...
    light += ambientLight(vec3(1.0), 0.03);
    // ...
}

虽然有点牵强,但我们还是要在调用 ambientLight() 时为每个参数添加注释:

void main()
{
    // ...

    light += ambientLight(
        vec3(1.0), // Light color
        0.03       // Light intensity
    );

    // ...
}

对于一会儿我们要看到的更复杂的灯光,这会派上用场。

另外,请注意强度和颜色本可以合并为一个参数,因为 lightColor 参数并不局限于 0 到 1 的范围。不过,分开更有意义,额外的好处是我们可以调整强度而无需改变颜色。

在进入下一个灯光之前,我们要把环境光放在一个单独的文件中。

在 src/shaders/ 中,创建一个 includes/ 文件夹。

在该文件夹中,创建一个 ambientLight.glsl 文件,并把 ambientLight 函数放进去:

vec3 ambientLight(vec3 lightColor, float lightIntensity)
{
    return lightColor * lightIntensity;
}

在 fragment.glsl 中,用 #include 替换该函数:

#include ../includes/ambientLight.glsl

void main()
{
    // ...
}

这样环境光就完成了。

平行光

平行光的工作方式非常相似,但强度会根据面的朝向和灯光的方向而变化。

如果面正对着灯光,它将接收到灯光的全部能量。如果面恰好成 90° 角,它将接收不到任何灯光。介于两者之间的值会被插值:

另外,请注意我们假设光线是平行的,且强度不随距离变化而保持恒定(就像 Three.js 的 DirectionalLight 一样)。

在 fragment.glsl 中,就在 main() 之前,复制 ambientLight 函数并将其重命名为 directionalLight:

vec3 directionalLight(vec3 lightColor, float lightIntensity)
{
    return lightColor * lightIntensity;
}

void main()
{
    // ...
}

我们本可以一开始就把这个函数放在不同的文件中,但我喜欢在开始时把函数放在同一个文件里,等它们准备好后再分离出去。

注释掉 ambientLight(),改为调用 directionalLight(),并使用以下参数:

void main()
{
    // ...

    // light += ambientLight(
    //     vec3(1.0), // Light color
    //     0.03       // Light intensity
    // );

    light += directionalLight(
        vec3(0.1, 0.1, 1.0), // Light color
        1.0                  // Light intensity
    );

    // ...
}

着色

我们希望这个值能根据面的朝向和灯光的方向而变化。

法线

让我们从面的朝向开始,我们已经知道需要什么了:法线(normal)。

在 vertex.glsl 中,创建一个 vec3 vNormal varying,并在 main() 的末尾把 normal 赋值给它:

varying vec3 vNormal;

void main()
{
    // ...

    // Varying
    vNormal = normal;
}

如果你还记得之前的课程,我们刚刚犯了两个错误。现在我们继续往下,等我们看到这些错误后再修复它们。

在 fragment shader 中,获取 vNormal:

varying vec3 vNormal;

把它作为 normal 传给 directionalLight 函数:

vec3 directionalLight(vec3 lightColor, float lightIntensity, vec3 normal)
{
    // ...
}

void main()
{
    // ...

    light += directionalLight(
        vec3(0.1, 0.1, 1.0), // Light color
        1.0,                 // Light intensity,
        vNormal              // Normal
    );

    // ...
}

灯光方向

我们已经有了面的朝向,现在需要灯光的方向。

为了方便起见,我们不是直接把方向传给函数,而是提供灯光的位置,并从它计算出方向。

给 directionalLight 函数添加一个 lightPosition 参数,并向它传入一个 vec3(0.0, 0.0, 3.0),它应该位于物体的前方:

vec3 directionalLight(vec3 lightColor, float lightIntensity, vec3 normal, vec3 lightPosition)
{
    // ...
}

void main()
{
    // ...

    light += directionalLight(
        vec3(0.1, 0.1, 1.0), // Light color
        1.0,                 // Light intensity,
        vNormal,             // Normal
        vec3(0.0, 0.0, 3.0)  // Light position
    );

    // ...
}

没错,我们传入了灯光的位置,但我们想要的是方向。两者的区别在于方向是归一化的(长度为 1)。

在 directionalLight 的开头,使用 normalize 对 lightPosition 进行归一化,并将其保存为 lightDirection:

vec3 directionalLight(vec3 lightColor, float lightIntensity, vec3 normal, vec3 lightPosition)
{
    vec3 lightDirection = normalize(lightPosition);

    return lightColor * lightIntensity;
}

辅助对象

为了理解光从哪里来,我们将在 scene 中添加一个平面来表示它。

在 script.js 中,使用 PlaneGeometry 和 MeshBasicMaterial 添加以下 Mesh:

/**
 * Light helpers
 */
const directionalLightHelper = new THREE.Mesh(
    new THREE.PlaneGeometry(),
    new THREE.MeshBasicMaterial()
)
scene.add(directionalLightHelper)
lesson demo video preview

调整位置和 material,使其与灯光匹配。

directionalLightHelper.material.color.setRGB(0.1, 0.1, 1)
directionalLightHelper.material.side = THREE.DoubleSide
directionalLightHelper.position.set(0, 0, 3)

Dot product

回到 fragment.glsl 和 directionalLight 函数。

我们把面的朝向表示为 normal,把灯光方向表示为 lightDirection。

这非常适合用 dot product 来解决。

在 directionalLight 中,对 normal 和 lightDirection 调用 dot,并将结果保存在一个 float shading 变量中:

vec3 directionalLight(vec3 lightColor, float lightIntensity, vec3 normal, vec3 lightPosition)
{
    // ...

    // Shading
    float shading = dot(normal, lightDirection);

    return lightColor * lightIntensity;
}

把结果作为 vec3 传给 return,看看它长什么样:

vec3 directionalLight(vec3 lightColor, float lightIntensity, vec3 normal, vec3 lightPosition)
{
    // ...

    // return lightColor * lightIntensity;
    return vec3(shading);
}
lesson demo video preview

不错,但有些不对劲。虽然灯光没有移动,但感觉它跟着物体的旋转在动。

这是因为我们忘记把物体变换应用到法线上,这就是我们设置法线时犯的第一个错误。

在 vertex.glsl 中,像我们对 position 所做的那样,把 modelMatrix 应用到 normal 上,但把第四个值设为 0.0,并将其保存为 vec4 modelNormal:

varying vec3 vNormal;

void main()
{
    // ...

    // Model normal
    vec4 modelNormal = modelMatrix * vec4(normal, 0.0);

    // Varying
    vNormal = normal;
}

然后把它赋值给 vNormal 而不是 normal,但别忘了只取出 xyz,因为 vNormal 是一个 vec3:

varying vec3 vNormal;

void main()
{
    // ...

    // Varying
    vNormal = modelNormal.xyz;
}
lesson demo video preview

提醒一下,在应用 modelMatrix 时,把 vec4 的第四个值设为 0.0 只会应用旋转和缩放,而不会应用平移:

回到 fragment.glsl 和 directionalLight 函数,这里不是把 shading 传给 return,而是可以把之前计算出的灯光乘以 shading:

vec3 directionalLight(vec3 lightColor, float lightIntensity, vec3 normal, vec3 lightPosition)
{
    // ...

    return lightColor * lightIntensity * shading;
}
lesson demo video preview

修复黑暗问题

让我们取消注释环境光,看看它们如何结合:

void main()
{
    // ...

    light += ambientLight(
        vec3(1.0), // Light color
        0.03       // Light intensity
    );

    light += directionalLight(
        vec3(0.1, 0.1, 1.0), // Light color
        1.0,                 // Light intensity,
        vNormal,             // Normal
        vec3(0.0, 0.0, 3.0)  // Light position
    );

    // ...
}
lesson demo video preview

有两个问题。既然有环境光,阴影中的部分就不应该是全黑的,而且阴影和受光部分之间的区域看起来怪异地发绿。

这两个问题都源于同一个问题。我们的平行光可能会返回小于 0 的值,因为当两个向量相反时,dot product 为负:

你听说过会产生少于 0 的光的灯吗?我也没听说过。

为了解决这个问题,在 directionalLight 函数中,使用 max 把 shading 限制在 0.0 以上:

vec3 directionalLight(vec3 lightColor, float lightIntensity, vec3 normal, vec3 lightPosition)
{
    // ...
    float shading = dot(normal, lightDirection);
    shading = max(0.0, shading);

    // ...
}
lesson demo video preview

好多了。

Specular

不幸的是,我们缺少了一个关键元素,那就是光的反射。没错,光正在照亮表面,但在现实生活中,我们还能看到光本身在物体表面上反射,我们称之为“specular”。

坚持住,这是本课最棘手的部分。

为了计算 specular,我们要计算光在表面上的反射向量,并将其与视图向量进行比较。

它们越是重合,specular 越强:

视图方向

首先,我们需要知道视图方向。它是一个从视图(我们的 camera 位置)指向 fragment 位置的向量。

多亏了内置的 cameraPosition uniform,我们已经有了 camera 位置。现在我们需要 fragment 位置,这可以通过 vertex shader 中的 modelPosition 获得。我们将使用一个 varying。

在 vertex shader 中,创建一个 vec3 vPosition,并在 main() 末尾把 modelPosition.xyz 赋值给它(就像我们对 vNormal 所做的那样):

varying vec3 vNormal;
varying vec3 vPosition;

void main()
{
    // ...

    // Varying
    vNormal = modelNormal.xyz;
    vPosition = modelPosition.xyz;
}

在 fragment.glsl 中,获取 vPosition:

varying vec3 vPosition;

为了计算视图方向,我们需要从 cameraPosition 指向 vPosition 的向量。

计算两个向量之间的向量,只需用起点减去终点即可:

在这种情况下,用 vPosition 减去 cameraPosition,我们将在 main() 的开头进行此操作,因为这个信息并非 directionalLight 专属,还可以用于其他用途:

void main()
{
    vec3 viewDirection = vPosition - cameraPosition;
    // ...
}

要获得实际的方向,我们只需确保向量的长度为 1,可以用 normalize 做到这一点:

void main()
{
    vec3 viewDirection = normalize(vPosition - cameraPosition);
    // ...
}

我们可以给 directionLight 函数添加一个 vec3 viewDirection 参数,并把我们刚计算出的 viewDirection 传给它:

vec3 directionalLight(vec3 lightColor, float lightIntensity, vec3 normal, vec3 lightPosition, vec3 viewDirection)
{
    // ...
}

void main()
{
    // ...

    light += directionalLight(
        vec3(0.1, 0.1, 1.0), // Light color
        1.0,                 // Light intensity,
        vNormal,             // Normal
        vec3(0.0, 0.0, 3.0), // Light position
        viewDirection        // View direction
    );

    // ...
}

反射

我们把表面朝向表示为 normal,把视图方向表示为 viewDirection,现在还需要光的反射:

好消息:GLSL 实现了一个名为 reflect 的函数,它正是做这件事的。

由于它特定于灯光本身,并且取决于灯光的位置,我们将在 directionalLight 函数中计算它。

调用 reflect,把 lightDirection 和 normal 传给它,并保存为 vec3 lightReflection:

vec3 directionalLight(vec3 lightColor, float lightIntensity, vec3 normal, vec3 lightPosition, vec3 viewDirection)
{
    vec3 lightDirection = normalize(lightPosition);
    vec3 lightReflection = reflect(lightDirection, normal);

    // ...
}

我们想要的是朝向表面而来的光的反射,但目前 lightDirection 恰好相反,它对应的是指向灯光方向的向量:

我们只需反转 lightDirection 即可:

vec3 directionalLight(vec3 lightColor, float lightIntensity, vec3 normal, vec3 lightPosition, vec3 viewDirection)
{
    vec3 lightDirection = normalize(lightPosition);
    vec3 lightReflection = reflect(- lightDirection, normal);

    // ...
}

Dot product

我们把视图方向表示为 viewDirection,把光的反射表示为 lightReflection。

我们准备好比较它们了,我们将使用常用的 dot。

调用 dot,把 viewDirection 和 lightReflection 传给它,并保存为 float specular:

vec3 directionalLight(vec3 lightColor, float lightIntensity, vec3 normal, vec3 lightPosition, vec3 viewDirection)
{
    // ...

    // Specular
    float specular = dot(lightReflection, viewDirection);

    return lightColor * lightIntensity * shading;
}

出于好奇,把它作为 vec3 传给 return:

vec3 directionalLight(vec3 lightColor, float lightIntensity, vec3 normal, vec3 lightPosition, vec3 viewDirection)
{
    // ...

    // Specular
    float specular = dot(lightReflection, viewDirection);

    // return lightColor * lightIntensity * shading;
    return vec3(specular);
}
lesson demo video preview

这是一个有趣的结果,但有几处需要我们调整。

首先,反射出现在错误的一侧。这是合理的,因为这与 dot product 返回的结果有关。

如果两个向量方向相同,我们会得到 1,这与我们想要的正好相反。

一个简单的修复方法是反转 dot 的结果:

vec3 directionalLight(vec3 lightColor, float lightIntensity, vec3 normal, vec3 lightPosition, vec3 viewDirection)
{
    // ...

    // Specular
    float specular = - dot(lightReflection, viewDirection);

    // ...
}
lesson demo video preview

第二个问题是 specular 的范围太大。我们期望反射点更小一些,可以通过降低数值来实现,这应该会让你联想到什么。我们将使用 pow,并向它传入 20.0,这个值相当高:

vec3 directionalLight(vec3 lightColor, float lightIntensity, vec3 normal, vec3 lightPosition, vec3 viewDirection)
{
    // ...

    // Specular
    float specular = - dot(lightReflection, viewDirection);
    specular = pow(specular, 20.0);

    // ...
}
lesson demo video preview

好多了。

如果你想知道为什么大部分表面看起来都略微被照亮,那是因为 ambientLight(),这不是问题。

你可能注意到物体背面有些奇怪。如果你使用偶数次幂,会得到一块白色区域,就像第二个 specular:

lesson demo video preview

如果你使用奇数次幂,会得到一块黑色区域:

lesson demo video preview

在这两种情况下,问题都是由 dot 的结果引起的,它在背面为负。

我们可以在应用 pow 之前,用 max 把数值限制在 0.0 以上来解决这个问题:

vec3 directionalLight(vec3 lightColor, float lightIntensity, vec3 normal, vec3 lightPosition, vec3 viewDirection)
{
    // ...

    // Specular
    float specular = - dot(lightReflection, viewDirection);
    specular = max(0.0, specular);
    specular = pow(specular, 20.0);

    // ...
}

它看起来几乎像是效果不错的 specular,但这个网格瑕疵是什么?

lesson demo video preview

在 fragment 中进行计算的目的是为了避免这类瑕疵。

而这实际上是我们设置法线时犯的第二个错误。

如果你还记得之前的课程,这个瑕疵是由于法线经插值后长度不再是 1 造成的:

在 main() 函数中,创建一个 vNormal 的归一化版本,保存为 normal,并把它传给 directionalLight 函数,而不是传 vNormal:

void main()
{
    vec3 normal = normalize(vNormal);
    // ...

    light += directionalLight(
        vec3(0.1, 0.1, 1.0), // Light color
        1.0,                 // Light intensity,
        normal,              // Normal
        vec3(0.0, 0.0, 3.0), // Light position
        viewDirection        // View direction
    );

    // ...
}
lesson demo video preview

让我们用参数来控制 specular 强度。

给 directionalLight 的参数添加一个 float specularPower,并在调用该函数时把它设为 20.0:

uniform vec3 uColor;

varying vec3 vNormal;
varying vec3 vPosition;

#include ../includes/ambientLight.glsl

vec3 directionalLight(vec3 lightColor, float lightIntensity, vec3 normal, vec3 lightPosition, vec3 viewDirection, float specularPower)
{
    // ...
    specular = pow(specular, specularPower);

    // ...
}

void main()
{
    // ...

    light += directionalLight(
        vec3(0.1, 0.1, 1.0), // Light color
        1.0,                 // Light intensity,
        normal,              // Normal
        vec3(0.0, 0.0, 3.0), // Light position
        viewDirection,       // View direction
        20.0                 // Specular power
    );

    // ...
}

目前我们只返回了 specular。我们需要把它与当前的灯光 shading 结合起来。

这正是见仁见智的地方,你可以得到相当不同的结果。

有人可能只是把它加到灯光上:

vec3 directionalLight(vec3 lightColor, float lightIntensity, vec3 normal, vec3 lightPosition, vec3 viewDirection, float specularPower)
{
    // ...

    return lightColor * lightIntensity * shading + specular;
}

这看起来不错,但并不准确,因为灯光应该是蓝色的,而且大概不该那么白、那么亮。

在这种情况下,我们还可以把它乘以 lightColor:

vec3 directionalLight(vec3 lightColor, float lightIntensity, vec3 normal, vec3 lightPosition, vec3 viewDirection, float specularPower)
{
    // ...

    return lightColor * lightIntensity * shading + lightColor * specular;
}

但它不也应该取决于 lightIntensity 吗?

vec3 directionalLight(vec3 lightColor, float lightIntensity, vec3 normal, vec3 lightPosition, vec3 viewDirection, float specularPower)
{
    // ...

    return lightColor * lightIntensity * shading + lightColor * lightIntensity * specular;
}

在这种情况下没有区别,因为 lightIntensity 被设为 1,但否则结果会不同。

这个公式可以这样简化:

vec3 directionalLight(vec3 lightColor, float lightIntensity, vec3 normal, vec3 lightPosition, vec3 viewDirection, float specularPower)
{
    // ...

    return lightColor * lightIntensity * (shading + specular);
}

尽管这个区别更不易察觉,我认为它合理,而且看起来真实,所以我们决定采用它。

这样 directionalLight 就完成了。

在进入下一个灯光之前,我们要把这个函数放在一个单独的文件中。

在 src/shaders/includes/ 中,创建一个 directionalLight.glsl 文件,并把 directionalLight 函数放进去:

vec3 directionalLight(vec3 lightColor, float lightIntensity, vec3 normal, vec3 lightPosition, vec3 viewDirection, float specularPower)
{
    vec3 lightDirection = normalize(lightPosition);
    vec3 lightReflection = reflect(- lightDirection, normal);

    // Shading
    float shading = dot(normal, lightDirection);
    shading = max(0.0, shading);

    // Specular
    float specular = - dot(lightReflection, viewDirection);
    specular = max(0.0, specular);
    specular = pow(specular, specularPower);

    return lightColor * lightIntensity * (shading + specular);
}

在 fragment.glsl 中,用 #include 替换该函数:

#include ../includes/ambientLight.glsl
#include ../includes/directionalLight.glsl

void main()
{
    // ...
}

点光源

让我们来处理最后一种灯光,也就是点光源。

别担心,由于它与平行光非常相似,我们只需处理两个主要区别:

在 fragment.glsl 中,就在 main() 之前,复制 directionalLight 函数并将其重命名为 pointLight:

vec3 pointLight(vec3 lightColor, float lightIntensity, vec3 normal, vec3 lightPosition, vec3 viewDirection, float specularPower)
{
    // ...
}

void main()
{
    // ...
}

注释掉 ambientLight() 和 directionalLight(),改为调用 pointLight(),把灯光颜色设为 vec3(1.0, 0.1, 0.1)(偏红色),灯光位置设为 vec3(0.0, 2.5, 0.0)(略高于 scene):

void main()
{
    // ...

    // light += ambientLight(
    //     vec3(1.0), // Light color
    //     0.03       // Light intensity
    // );

    // light += directionalLight(
    //     vec3(0.1, 0.1, 1.0), // Light color
    //     1.0,                 // Light intensity,
    //     normal,              // Normal
    //     vec3(0.0, 0.0, 3.0), // Light position
    //     viewDirection,       // View direction
    //     20.0                 // Specular power
    // );

    light += pointLight(
        vec3(1.0, 0.1, 0.1), // Light color
        1.0,                 // Light intensity,
        normal,              // Normal
        vec3(0.0, 2.5, 0.0), // Light position
        viewDirection,       // View direction
        20.0                 // Specular power
    );

    // ...
}

辅助对象

就像平行光那样,让我们添加一个辅助对象。

在 script.js 中,添加以下 Mesh,这次使用 IcosahedronGeometry、MeshBasicMaterial,以及与我们写在 shader 中的灯光相匹配的参数:

const pointLightHelper = new THREE.Mesh(
    new THREE.IcosahedronGeometry(0.1, 2),
    new THREE.MeshBasicMaterial()
)
pointLightHelper.material.color.setRGB(1, 0.1, 0.1)
pointLightHelper.position.set(0, 2.5, 0)
scene.add(pointLightHelper)

方向

我们要修复的第一件事是朝向。

灯光应该位于 scene 中心上方 2.5 个单位处(略高于 Suzanne)。但如果你观察球体,会发现它看起来并非如此:

lesson demo video preview

我们需要修正 lightDirection,使其成为一个从物体表面指向灯光方向的向量:

为此,我们需要 fragment 的位置。幸运的是,我们在 fragment shader 中已经可以通过 vPosition 访问到它了。

给 pointLight 函数添加一个 vec3 position 参数,并在调用 pointLight 函数时传入 vPosition:

// ...

vec3 pointLight(vec3 lightColor, float lightIntensity, vec3 normal, vec3 lightPosition, vec3 viewDirection, float specularPower, vec3 position)
{
    // ...
}

void main()
{
    // ...

    light += pointLight(
        vec3(1.0, 0.1, 0.1), // Light color
        1.0,                 // Light intensity,
        normal,              // Normal
        vec3(0.0, 2.5, 0.0), // Light position
        viewDirection,       // View direction
        20.0,                // Specular power
        vPosition            // Position
    );

    // ...
}

为了计算正确的朝向,我们首先需要从 position 指向 lightPosition 的向量。我们可以通过用 lightPosition 减去 position 来实现,并把它保存为 vec3 lightDelta:

vec3 pointLight(vec3 lightColor, float lightIntensity, vec3 normal, vec3 lightPosition, vec3 viewDirection, float specularPower, vec3 position)
{
    vec3 lightDelta = lightPosition - position;
    // ...
}

灯光方向不过是归一化后的 lightDelta,在计算 lightDirection 时,我们只需把 lightPosition 替换为 lightDelta:

vec3 pointLight(vec3 lightColor, float lightIntensity, vec3 normal, vec3 lightPosition, vec3 viewDirection, float specularPower, vec3 position)
{
    vec3 lightDelta = lightPosition - position;
    vec3 lightDirection = normalize(lightDelta);
    // ...
}
lesson demo video preview

衰减

让点光源正常工作的下一个也是最后一个部分,是距离衰减。

我们希望灯光强度随距离而减弱。

首先,我们需要知道表面离灯光有多远,而我们已经有了这个信息。

由于 lightDelta 是一个从 fragment 位置指向灯光的向量,它的长度就是我们需要的距离。

创建一个 float lightDistance 变量,并使用 length 把 lightDelta 的长度赋值给它:

vec3 pointLight(vec3 lightColor, float lightIntensity, vec3 normal, vec3 lightPosition, vec3 viewDirection, float specularPower, vec3 position)
{
    vec3 lightDelta = lightPosition - position;
    float lightDistance = length(lightDelta);
    // ...
}

我们现在希望当距离为 0 时该值从 1 开始,并逐渐减小。

在 return 之前,创建一个 decay 变量,赋值为 1.0,并减去 lightDistance:

vec3 pointLight(vec3 lightColor, float lightIntensity, vec3 normal, vec3 lightPosition, vec3 viewDirection, float specularPower, vec3 position)
{
    // ...

    // Decay
    float decay = 1.0 - lightDistance;

    return lightColor * lightIntensity * (shading + specular);
}

返回 decay 组成的 vec3(),看看最终得到什么:

vec3 pointLight(vec3 lightColor, float lightIntensity, vec3 normal, vec3 lightPosition, vec3 viewDirection, float specularPower, vec3 position)
{
    // ...

    // Decay
    float decay = 1.0 - lightDistance;

    // return lightColor * lightIntensity * (shading + specular);
    return vec3(decay);
}

我们什么也看不到,因为灯光衰减得太快了。点光源目前在 Suzanne 上方 2.5 个单位处,而经过 1 个单位后,decay 就已经降到 0.0 了:

我们可以通过把 lightDistance 乘以一个像 0.3 这样的小数来解决:

vec3 pointLight(vec3 lightColor, float lightIntensity, vec3 normal, vec3 lightPosition, vec3 viewDirection, float specularPower, vec3 position)
{
    // ...
    float decay = 1.0 - lightDistance * 0.3;

    // ...
}

而且我们可以通过参数来控制它。

给 pointLight 添加一个 float lightDecay 参数,并在调用该函数时把它设为 0.25,然后在计算 decay 时把它与 lightDistance 相乘:

vec3 pointLight(vec3 lightColor, float lightIntensity, vec3 normal, vec3 lightPosition, vec3 viewDirection, float specularPower, vec3 position, float lightDecay)
{
    // ...

    // Decay
    float decay = 1.0 - lightDistance * lightDecay;

    // ...
}

void main()
{
    // ...

    light += pointLight(
        vec3(1.0, 0.1, 0.1), // Light color
        1.0,                 // Light intensity,
        normal,              // Normal
        vec3(0.0, 2.5, 0.0), // Light position
        viewDirection,       // View direction
        20.0,                // Specular power
        vPosition,           // Position
        0.25                 // Light decay
    );

    // ...
}

现在我们可以返回灯光,并把它乘以 decay:

vec3 pointLight(vec3 lightColor, float lightIntensity, vec3 normal, vec3 lightPosition, vec3 viewDirection, float specularPower, vec3 position, float lightDecay)
{
    // ...

    return lightColor * lightIntensity * decay * (shading + specular);
}

别忘记使用 max() 把 decay 限制在 0.0 以上,这样潜在的负值就不会搞乱其他灯光:

vec3 pointLight(vec3 lightColor, float lightIntensity, vec3 normal, vec3 lightPosition, vec3 viewDirection, float specularPower, vec3 position, float lightDecay)
{
    // ...

    // Decay
    float decay = 1.0 - lightDistance * lightDecay;
    decay = max(0.0, decay);

    // ...
}

这样点光源就完成了。让我们把它放在一个单独的文件中。

在 src/shaders/includes/ 中,创建一个 pointLight.glsl 文件,并把 pointLight 函数放进去:

vec3 pointLight(vec3 lightColor, float lightIntensity, vec3 normal, vec3 lightPosition, vec3 viewDirection, float specularPower, vec3 position, float lightDecay)
{
    vec3 lightDelta = lightPosition - position;
    float lightDistance = length(lightDelta);
    vec3 lightDirection = normalize(lightDelta);
    vec3 lightReflection = reflect(- lightDirection, normal);

    // Shading
    float shading = dot(normal, lightDirection);
    shading = max(0.0, shading);

    // Specular
    float specular = - dot(lightReflection, viewDirection);
    specular = max(0.0, specular);
    specular = pow(specular, specularPower);

    // Decay
    float decay = 1.0 - lightDistance * lightDecay;
    decay = max(0.0, decay);

    return lightColor * lightIntensity * decay * (shading + specular);
}

在 fragment.glsl 中,用 #include 替换该函数:

#include ../includes/ambientLight.glsl
#include ../includes/directionalLight.glsl
#include ../includes/pointLight.glsl

void main()
{
    // ...
}

结语

灯光部分我们就完成了,我们可以把这三个都取消注释:

void main()
{
    // ...

    light += ambientLight(
        vec3(1.0), // Light color
        0.03       // Light intensity
    );

    light += directionalLight(
        vec3(0.1, 0.1, 1.0), // Light color
        1.0,                 // Light intensity,
        normal,              // Normal
        vec3(0.0, 0.0, 3.0), // Light position
        viewDirection,       // View direction
        20.0                 // Specular power
    );

    light += pointLight(
        vec3(1.0, 0.1, 0.1), // Light color
        1.0,                 // Light intensity,
        normal,              // Normal
        vec3(0.0, 2.5, 0.0), // Light position
        viewDirection,       // View direction
        20.0,                // Specular power
        vPosition,           // Position
        0.2                  // Light decay
    );

    // ...
}

我们使用的这种结构好在,只要用不同的参数再次调用这些函数,就能添加更多灯光:

void main()
{
    // ...

    light += ambientLight(
        // ...
    );

    light += directionalLight(
        // ...
    );

    light += pointLight(
        // ...
    );

    light += pointLight(
        vec3(0.1, 1.0, 0.5), // Light color
        1.0,                 // Light intensity,
        normal,              // Normal
        vec3(2.0, 2.0, 2.0), // Light position
        viewDirection,       // View direction
        20.0,                // Specular power
        vPosition,           // Position
        0.2                  // Light decay
    );

    // ...
}

如果你愿意,可以在 scene 中添加对应的辅助对象:

const pointLightHelper2 = new THREE.Mesh(
    new THREE.IcosahedronGeometry(0.1, 2),
    new THREE.MeshBasicMaterial()
)
pointLightHelper2.material.color.setRGB(0.1, 1.0, 0.5)
pointLightHelper2.position.set(2, 2, 2)
scene.add(pointLightHelper2)

尽管它不是基于物理的,而且我们走了些捷径,尤其是在 specular 和点光源衰减方面,但它看起来真实,而且性能良好。

我们现在理解了光照着色的基础知识,可以用它为自定义 shader 添加酷炫的效果。

要明白我们只是触及了这个话题的皮毛,光照可以走得更远,尤其当你想到 environment maps、PBR、阴影、法线等时。

进阶

一如既往,请随意深入探索。以下是一些建议:

如果你想进一步了解光照着色,可以看看 OGLDEV 频道,尤其是以下视频: